import type { LogRecord, Sink } from "@logtape/logtape";

/**
 * The type for a field pattern used in redaction.  A string or a regular
 * expression that matches field names.
 * @since 0.10.0
 */
export type FieldPattern = string | RegExp;

/**
 * An array of field patterns used for redaction.  Each pattern can be
 * a string or a regular expression that matches field names.
 * @since 0.10.0
 */
export type FieldPatterns = FieldPattern[];

/**
 * Default field patterns for redaction.  These patterns will match
 * common sensitive fields such as passwords, tokens, and personal
 * information.
 * @since 0.10.0
 */
export const DEFAULT_REDACT_FIELDS: FieldPatterns = [
  /pass(?:code|phrase|word)/i,
  /secret/i,
  /token/i,
  /key/i,
  /credential/i,
  /auth/i,
  /signature/i,
  /sensitive/i,
  /private/i,
  /ssn/i,
  /email/i,
  /phone/i,
  /address/i,
];

/**
 * Options for redacting fields in a {@link LogRecord}.  Used by
 * the {@link redactByField} function.
 * @since 0.10.0
 */
export interface FieldRedactionOptions {
  /**
   * The field patterns to match against.  This can be an array of
   * strings or regular expressions.  If a field matches any of the
   * patterns, it will be redacted.
   * @defaultValue {@link DEFAULT_REDACT_FIELDS}
   */
  readonly fieldPatterns: FieldPatterns;

  /**
   * The action to perform on the matched fields.  If not provided,
   * the default action is to delete the field from the properties.
   * If a function is provided, it will be called with the
   * value of the field, and the return value will be used to replace
   * the field in the properties.
   * If the action is `"delete"`, the field will be removed from the
   * properties.
   * @default `"delete"`
   */
  readonly action?: "delete" | ((value: unknown) => unknown);
}

/**
 * Redacts properties and message values in a {@link LogRecord} based on the
 * provided field patterns and action.
 *
 * Note that it is a decorator which wraps the sink and redacts properties
 * and message values before passing them to the sink.
 *
 * For string templates (e.g., `"Hello, {name}!"`), placeholder names are
 * matched against the field patterns to determine which values to redact.
 *
 * For tagged template literals (e.g., `` `Hello, ${name}!` ``), redaction
 * is performed by comparing message values with redacted property values.
 *
 * @example
 * ```ts
 * import { getConsoleSink } from "@logtape/logtape";
 * import { redactByField } from "@logtape/redaction";
 *
 * const sink = redactByField(getConsoleSink());
 * ```
 *
 * @param sink The sink to wrap.
 * @param options The redaction options.
 * @returns The wrapped sink.
 * @since 0.10.0
 */
export function redactByField(
  sink: Sink | Sink & Disposable | Sink & AsyncDisposable,
  options: FieldRedactionOptions | FieldPatterns = DEFAULT_REDACT_FIELDS,
): Sink | Sink & Disposable | Sink & AsyncDisposable {
  const opts = Array.isArray(options) ? { fieldPatterns: options } : options;
  const wrapped = (record: LogRecord) => {
    const redactedProperties = redactProperties(record.properties, opts);
    let redactedMessage = record.message;

    if (typeof record.rawMessage === "string") {
      // String template: redact by placeholder names
      const placeholders = extractPlaceholderNames(record.rawMessage);
      const { redactedIndices, wildcardIndices } =
        getRedactedPlaceholderIndices(
          placeholders,
          opts.fieldPatterns,
        );
      if (redactedIndices.size > 0 || wildcardIndices.size > 0) {
        redactedMessage = redactMessageArray(
          record.message,
          redactedIndices,
          wildcardIndices,
          redactedProperties,
          opts.action,
        );
      }
    } else {
      // Tagged template: redact by comparing values
      const redactedValues = getRedactedValues(
        record.properties,
        redactedProperties,
      );
      if (redactedValues.size > 0) {
        redactedMessage = redactMessageByValues(record.message, redactedValues);
      }
    }

    sink({
      ...record,
      message: redactedMessage,
      properties: redactedProperties,
    });
  };
  if (Symbol.dispose in sink) wrapped[Symbol.dispose] = sink[Symbol.dispose];
  if (Symbol.asyncDispose in sink) {
    wrapped[Symbol.asyncDispose] = sink[Symbol.asyncDispose];
  }
  return wrapped;
}

/**
 * Redacts properties from an object based on specified field patterns.
 *
 * This function creates a shallow copy of the input object and applies
 * redaction rules to its properties. For properties that match the redaction
 * patterns, the function either removes them or transforms their values based
 * on the provided action.
 *
 * The redaction process is recursive and will be applied to nested objects
 * as well, allowing for deep redaction of sensitive data in complex object
 * structures.
 * @param properties The properties to redact.
 * @param options The redaction options.
 * @returns The redacted properties.
 * @since 0.10.0
 */
export function redactProperties(
  properties: Record<string, unknown>,
  options: FieldRedactionOptions,
): Record<string, unknown> {
  const copy = { ...properties };
  for (const field in copy) {
    if (shouldFieldRedacted(field, options.fieldPatterns)) {
      if (options.action == null || options.action === "delete") {
        delete copy[field];
      } else {
        copy[field] = options.action(copy[field]);
      }
      continue;
    }
    const value = copy[field];
    // Check if value is an array:
    if (Array.isArray(value)) {
      copy[field] = value.map((item) => {
        if (
          typeof item === "object" && item !== null &&
          (Object.getPrototypeOf(item) === Object.prototype ||
            Object.getPrototypeOf(item) === null)
        ) {
          // @ts-ignore: item is always Record<string, unknown>
          return redactProperties(item, options);
        }
        return item;
      });
      // Check if value is a vanilla object:
    } else if (
      typeof value === "object" && value !== null &&
      (Object.getPrototypeOf(value) === Object.prototype ||
        Object.getPrototypeOf(value) === null)
    ) {
      // @ts-ignore: value is always Record<string, unknown>
      copy[field] = redactProperties(value, options);
    }
  }
  return copy;
}

/**
 * Checks if a field should be redacted based on the provided field patterns.
 * @param field The field name to check.
 * @param fieldPatterns The field patterns to match against.
 * @returns `true` if the field should be redacted, `false` otherwise.
 * @since 0.10.0
 */
export function shouldFieldRedacted(
  field: string,
  fieldPatterns: FieldPatterns,
): boolean {
  for (const fieldPattern of fieldPatterns) {
    if (typeof fieldPattern === "string") {
      if (fieldPattern === field) return true;
    } else {
      if (fieldPattern.test(field)) return true;
    }
  }
  return false;
}

/**
 * Extracts placeholder names from a message template string in order.
 * @param template The message template string.
 * @returns An array of placeholder names in the order they appear.
 */
function extractPlaceholderNames(template: string): string[] {
  const placeholders: string[] = [];
  for (let i = 0; i < template.length; i++) {
    if (template[i] === "{") {
      // Check for escaped brace
      if (i + 1 < template.length && template[i + 1] === "{") {
        i++;
        continue;
      }
      const closeIndex = template.indexOf("}", i + 1);
      if (closeIndex === -1) continue;
      const key = template.slice(i + 1, closeIndex).trim();
      placeholders.push(key);
      i = closeIndex;
    }
  }
  return placeholders;
}

/**
 * Parses a property path into its segments.
 * @param path The property path (e.g., "user.password" or "users[0].email").
 * @returns An array of path segments.
 */
function parsePathSegments(path: string): string[] {
  const segments: string[] = [];
  let current = "";
  for (const char of path) {
    if (char === "." || char === "[") {
      if (current) segments.push(current);
      current = "";
    } else if (char === "]" || char === "?") {
      // Skip these characters
    } else {
      current += char;
    }
  }
  if (current) segments.push(current);
  return segments;
}

/**
 * Determines which placeholder indices should be redacted based on field
 * patterns, and which are wildcard placeholders.
 * @param placeholders Array of placeholder names from the template.
 * @param fieldPatterns Field patterns to match against.
 * @returns Object with redactedIndices and wildcardIndices.
 */
function getRedactedPlaceholderIndices(
  placeholders: string[],
  fieldPatterns: FieldPatterns,
): { redactedIndices: Set<number>; wildcardIndices: Set<number> } {
  const redactedIndices = new Set<number>();
  const wildcardIndices = new Set<number>();

  for (let i = 0; i < placeholders.length; i++) {
    const placeholder = placeholders[i];

    // Track wildcard {*} separately
    if (placeholder === "*") {
      wildcardIndices.add(i);
      continue;
    }

    // Check the full placeholder name
    if (shouldFieldRedacted(placeholder, fieldPatterns)) {
      redactedIndices.add(i);
      continue;
    }
    // For nested paths, check each segment
    const segments = parsePathSegments(placeholder);
    for (const segment of segments) {
      if (shouldFieldRedacted(segment, fieldPatterns)) {
        redactedIndices.add(i);
        break;
      }
    }
  }
  return { redactedIndices, wildcardIndices };
}

/**
 * Redacts values in the message array based on the redacted placeholder
 * indices and wildcard indices.
 * @param message The original message array.
 * @param redactedIndices Set of placeholder indices to redact.
 * @param wildcardIndices Set of wildcard placeholder indices.
 * @param redactedProperties The redacted properties object.
 * @param action The redaction action.
 * @returns New message array with redacted values.
 */
function redactMessageArray(
  message: readonly unknown[],
  redactedIndices: Set<number>,
  wildcardIndices: Set<number>,
  redactedProperties: Record<string, unknown>,
  action: "delete" | ((value: unknown) => unknown) | undefined,
): readonly unknown[] {
  if (redactedIndices.size === 0 && wildcardIndices.size === 0) return message;

  const result: unknown[] = [];
  let placeholderIndex = 0;

  for (let i = 0; i < message.length; i++) {
    if (i % 2 === 0) {
      // Even index: text segment
      result.push(message[i]);
    } else {
      // Odd index: value/placeholder
      if (wildcardIndices.has(placeholderIndex)) {
        // Wildcard {*}: replace with redacted properties
        result.push(redactedProperties);
      } else if (redactedIndices.has(placeholderIndex)) {
        if (action == null || action === "delete") {
          result.push("");
        } else {
          result.push(action(message[i]));
        }
      } else {
        result.push(message[i]);
      }
      placeholderIndex++;
    }
  }
  return result;
}

/**
 * Collects redacted value mappings from original to redacted properties.
 * @param original The original properties.
 * @param redacted The redacted properties.
 * @param map The map to populate with original -> redacted value pairs.
 */
function collectRedactedValues(
  original: Record<string, unknown>,
  redacted: Record<string, unknown>,
  map: Map<unknown, unknown>,
): void {
  for (const key in original) {
    const origVal = original[key];
    const redVal = redacted[key];

    if (origVal !== redVal) {
      map.set(origVal, redVal);
    }

    // Recurse into nested objects
    if (
      typeof origVal === "object" && origVal !== null &&
      typeof redVal === "object" && redVal !== null &&
      !Array.isArray(origVal)
    ) {
      collectRedactedValues(
        origVal as Record<string, unknown>,
        redVal as Record<string, unknown>,
        map,
      );
    }
  }
}

/**
 * Gets a map of original values to their redacted replacements.
 * @param original The original properties.
 * @param redacted The redacted properties.
 * @returns A map of original -> redacted values.
 */
function getRedactedValues(
  original: Record<string, unknown>,
  redacted: Record<string, unknown>,
): Map<unknown, unknown> {
  const map = new Map<unknown, unknown>();
  collectRedactedValues(original, redacted, map);
  return map;
}

/**
 * Redacts message array values by comparing with redacted property values.
 * Used for tagged template literals where placeholder names are not available.
 * @param message The original message array.
 * @param redactedValues Map of original -> redacted values.
 * @returns New message array with redacted values.
 */
function redactMessageByValues(
  message: readonly unknown[],
  redactedValues: Map<unknown, unknown>,
): readonly unknown[] {
  if (redactedValues.size === 0) return message;

  const result: unknown[] = [];
  for (let i = 0; i < message.length; i++) {
    if (i % 2 === 0) {
      result.push(message[i]);
    } else {
      const val = message[i];
      if (redactedValues.has(val)) {
        result.push(redactedValues.get(val));
      } else {
        result.push(val);
      }
    }
  }
  return result;
}
